[Проект] Интерактивный HTML-Редактор

Локальная версия с подсветкой

editor.html:

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Интерактивный HTML редактор</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Универсальный путь: работает и на компе, и на сервере FastAPI -->
<link href="static/prism.css" rel="stylesheet" id="prism-theme" />
<style>
  html, body {
    margin: 0; padding: 0; height: 100%; overflow: hidden;
    font-family: Consolas, Monaco, 'Andale Mono', monospace; 
    background-color: #121212; color: #e0e0e0;
  }
  body { display: flex; flex-direction: column; height: 100%; }
  #container { flex: 1; display: flex; width: 100vw; height: 100%; overflow: hidden; }

  .editor-wrapper { 
    position: relative; width: 50%; height: 100%; overflow: hidden; background-color: #1e1e1e; 
  }

  /* Сверхточное наложение слоёв */
  #editor, #highlighting {
    margin: 0; padding: 15px; border: none; width: 100%; height: 100%;
    font-size: 15px; 
    font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 
    line-height: 1.5;
    position: absolute; top: 0; left: 0; box-sizing: border-box;
    white-space: pre; overflow: auto; tab-size: 2;
  }

  #editor {
    color: transparent; background: transparent; caret-color: #00ff00;
    z-index: 2; resize: none; outline: none;
  }

  #highlighting { z-index: 1; pointer-events: none; }

  /* Сброс стилей Prism */
  pre[class*="language-"] { margin: 0 !important; padding: 0 !important; background: transparent !important; }
  code[class*="language-"] { text-shadow: none !important; padding: 0 !important; }

  iframe#preview { width: 50%; border: none; background: white; height: 100%; }
  #dragbar { width: 6px; cursor: col-resize; background-color: #333; user-select: none; z-index: 20; transition: background 0.2s; }
  #dragbar:hover { background-color: #4da6ff; }

  button.btn-icon {
    position: absolute; top: 10px; width: 34px; height: 34px;
    background: #2a2a2a; border: 1px solid #444; cursor: pointer;
    border-radius: 6px; z-index: 30; color: #fff;
    display: flex; align-items: center; justify-content: center;
    transition: all 0.2s; opacity: 0.9;
  }
  button.btn-icon:hover { opacity: 1; background: #333; border-color: #4da6ff; }
  #btn-copy { right: 50px; }
  #btn-theme { right: 10px; }
  button.btn-icon svg { width: 18px; height: 18px; fill: currentColor; }

  /* Светлая тема */
  body.light { background-color: #f0f0f0; }
  body.light .editor-wrapper { background: #ffffff; }
  body.light #editor { caret-color: #000; }
  body.light #dragbar { background-color: #ddd; }

  @media (max-width: 768px) {
    #container { flex-direction: column; }
    .editor-wrapper, iframe#preview { width: 100% !important; }
    #dragbar { cursor: row-resize; width: 100%; height: 6px; }
  }
</style>
</head>
<body>
  <div id="container">
    <div class="editor-wrapper" id="editor-panel">
      <textarea id="editor" spellcheck="false" placeholder="Вставьте ваш HTML код сюда..."></textarea>
      <pre id="highlighting" aria-hidden="true"><code class="language-html" id="highlighting-content"></code></pre>

      <button class="btn-icon" id="btn-copy" title="Скопировать всё">
        <svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14 c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
      </button>
      <button class="btn-icon" id="btn-theme" title="Сменить тему">
        <svg viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 0112.21 3a7 7 0 108.79 9.79z"/></svg>
      </button>
    </div>
    <div id="dragbar"></div>
    <iframe id="preview" sandbox="allow-scripts allow-same-origin allow-modals"></iframe>
  </div>

<!-- Универсальный путь к JS -->
<script src="static/prism.js"></script>

<script>
  const editor = document.getElementById('editor');
  const highlightingContent = document.getElementById('highlighting-content');
  const highlighting = document.getElementById('highlighting');
  const preview = document.getElementById('preview');
  const btnCopy = document.getElementById('btn-copy');
  const btnTheme = document.getElementById('btn-theme');
  const dragbar = document.getElementById('dragbar');
  const editorWrapper = document.getElementById('editor-panel');

  function update() {
    let content = editor.value;
    let escaped = content.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
    if (content[content.length - 1] === "\n") escaped += " ";

    highlightingContent.innerHTML = escaped;

    if (typeof Prism !== 'undefined') {
      highlightingContent.className = 'language-html';
      Prism.highlightElement(highlightingContent);
    }

    preview.srcdoc = content;
    localStorage.setItem('editor_code_flexy', content);
  }

  function syncScroll() {
    highlighting.scrollTop = editor.scrollTop;
    highlighting.scrollLeft = editor.scrollLeft;
  }

  editor.addEventListener('scroll', syncScroll);
  editor.addEventListener('input', () => {
    update();
    syncScroll();
  });

  editor.onkeydown = function(e) {
    if (e.key === 'Tab') {
      e.preventDefault();
      const start = this.selectionStart;
      this.value = this.value.substring(0, start) + "  " + this.value.substring(this.selectionEnd);
      this.selectionEnd = start + 2;
      update();
    }
  };

  window.addEventListener('load', () => {
    const saved = localStorage.getItem('editor_code_flexy');
    if (saved) { editor.value = saved; } else {
      editor.value = "<!-- Локальная подсветка активна! -->\n<html>\n<style>\n  h1 { color: #00ff00; }\n</style>\n<script>\n  console.log('Flexy AI Editor Loaded');\n<\/script>\n<body>\n  <h1>Всё работает!</h1>\n</body>\n</html>";
    }
    update();
    syncScroll();
  });

  btnCopy.onclick = () => {
    navigator.clipboard.writeText(editor.value);
    const original = btnCopy.innerHTML;
    btnCopy.innerHTML = '<span style="color:#4da6ff; font-size:12px">OK</span>';
    setTimeout(() => btnCopy.innerHTML = original, 1000);
  };

  btnTheme.onclick = () => {
    document.body.classList.toggle('light');
  };

  let isDragging = false;
  dragbar.onmousedown = (e) => { isDragging = true; document.body.style.cursor = window.innerWidth > 768 ? 'col-resize' : 'row-resize'; };
  document.onmouseup = () => { isDragging = false; document.body.style.cursor = 'default'; };
  document.onmousemove = (e) => {
    if (!isDragging) return;
    if (window.innerWidth > 768) {
      let x = e.clientX;
      if (x > 100 && x < window.innerWidth - 100) editorWrapper.style.width = x + 'px';
    } else {
      let y = e.clientY;
      if (y > 50 && y < window.innerHeight - 50) editorWrapper.style.height = y + 'px';
    }
  };
</script>
</body>
</html>

main.py :

from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles # Добавь это

app = FastAPI()

# Подключаем папку static по пути /static
app.mount("/static", StaticFiles(directory="static"), name="static")

templates = Jinja2Templates(directory="templates")

@app.get("/", response_class=HTMLResponse)
async def editor(request: Request):
    return templates.TemplateResponse("editor.html", {"request": request})

Для подсветки синтаксиса

Cоздать в папке проекта папку static. положить туда prism.css и! prism.js

Файлы можно взять prismjs.com/download выбрать Minified version Languages: Markup + HTML + XML + SVG + MathML + SSML + Atom + RSS CSS, C-like, JavaScript Themes: Tomorrow Night

CDN Версия c подсветкой

editor.html:

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Flexy Interactive Editor</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Стили Prism: тема Tomorrow Night -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" crossorigin="anonymous" />
<style>
  html, body {
    margin: 0; padding: 0; height: 100%; overflow: hidden;
    font-family: 'Consolas', 'Monaco', 'Andale Mono', monospace; 
    background-color: #121212; color: #e0e0e0;
  }
  body { display: flex; flex-direction: column; height: 100%; }
  #container { flex: 1; display: flex; width: 100vw; height: 100%; overflow: hidden; }

  .editor-wrapper { 
    position: relative; width: 50%; height: 100%; overflow: hidden; background-color: #1e1e1e; 
  }

  /* Сверхточное наложение слоёв ввода и подсветки */
  #editor, #highlighting {
    margin: 0; padding: 20px; border: none; width: 100%; height: 100%;
    font-size: 16px; 
    font-family: 'Consolas', 'Monaco', 'Andale Mono', monospace; 
    line-height: 1.5;
    position: absolute; top: 0; left: 0; box-sizing: border-box;
    white-space: pre; overflow: auto; tab-size: 2;
    word-break: break-all;
  }

  #editor {
    color: transparent; background: transparent; caret-color: #00ff00;
    z-index: 2; resize: none; outline: none;
  }

  #highlighting { z-index: 1; pointer-events: none; }

  /* Фиксы для Prism: убираем лишние отступы и тени */
  pre[class*="language-"] { margin: 0 !important; padding: 0 !important; background: transparent !important; }
  code[class*="language-"] { 
    text-shadow: none !important; 
    padding: 0 !important; 
    font-family: inherit !important;
    background: transparent !important;
  }

  iframe#preview { width: 50%; border: none; background: white; height: 100%; }
  #dragbar { width: 6px; cursor: col-resize; background-color: #333; user-select: none; z-index: 20; transition: background 0.2s; }
  #dragbar:hover { background-color: #4da6ff; }

  button.btn-icon {
    position: absolute; top: 10px; width: 34px; height: 34px;
    background: #2a2a2a; border: 1px solid #444; cursor: pointer;
    border-radius: 6px; z-index: 30; color: #fff;
    display: flex; align-items: center; justify-content: center;
    transition: all 0.2s; opacity: 0.9;
  }
  button.btn-icon:hover { opacity: 1; background: #333; border-color: #4da6ff; }
  #btn-copy { right: 50px; }
  #btn-theme { right: 10px; }
  button.btn-icon svg { width: 18px; height: 18px; fill: currentColor; }

  /* Светлая тема */
  body.light { background-color: #f0f0f0; }
  body.light .editor-wrapper { background: #ffffff; }
  body.light #editor { caret-color: #000; }
  body.light #dragbar { background-color: #ddd; }

  @media (max-width: 768px) {
    #container { flex-direction: column; }
    .editor-wrapper, iframe#preview { width: 100% !important; }
    #dragbar { cursor: row-resize; width: 100%; height: 6px; }
  }
</style>
</head>
<body>
  <div id="container">
    <div class="editor-wrapper" id="editor-panel">
      <textarea id="editor" spellcheck="false" placeholder="Вставьте ваш HTML код сюда..."></textarea>
      <pre id="highlighting" aria-hidden="true"><code class="language-html" id="highlighting-content"></code></pre>

      <button class="btn-icon" id="btn-copy" title="Скопировать всё">
        <svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14 c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
      </button>
      <button class="btn-icon" id="btn-theme" title="Сменить тему">
        <svg viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 0112.21 3a7 7 0 108.79 9.79z"/></svg>
      </button>
    </div>
    <div id="dragbar"></div>
    <iframe id="preview" sandbox="allow-scripts allow-same-origin allow-modals"></iframe>
  </div>

<!-- Подключаем скрипты Prism через CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup.min.js" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js" crossorigin="anonymous"></script>

<script>
  const editor = document.getElementById('editor');
  const highlightingContent = document.getElementById('highlighting-content');
  const highlighting = document.getElementById('highlighting');
  const preview = document.getElementById('preview');
  const btnCopy = document.getElementById('btn-copy');
  const btnTheme = document.getElementById('btn-theme');
  const dragbar = document.getElementById('dragbar');
  const editorWrapper = document.getElementById('editor-panel');

  function update() {
    let content = editor.value;

    // Экранирование для корректного отображения в слое подсветки
    let escaped = content.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
    if (content[content.length - 1] === "\n") escaped += " ";

    highlightingContent.innerHTML = escaped;

    // Инициируем подсветку Prism
    if (window.Prism) {
      Prism.highlightElement(highlightingContent);
    }

    // Обновляем превью в iframe
    preview.srcdoc = content;
    localStorage.setItem('editor_code_flexy', content);
  }

  function syncScroll() {
    highlighting.scrollTop = editor.scrollTop;
    highlighting.scrollLeft = editor.scrollLeft;
  }

  editor.addEventListener('scroll', syncScroll);
  editor.addEventListener('input', () => {
    update();
    syncScroll();
  });

  // Вставка Tab как 2 пробелов
  editor.onkeydown = function(e) {
    if (e.key === 'Tab') {
      e.preventDefault();
      const start = this.selectionStart;
      this.value = this.value.substring(0, start) + "  " + this.value.substring(this.selectionEnd);
      this.selectionEnd = start + 2;
      update();
      syncScroll();
    }
  };

  window.addEventListener('load', () => {
    const saved = localStorage.getItem('editor_code_flexy');
    if (saved) { 
      editor.value = saved; 
    } else {
      editor.value = "<!-- Полная подсветка (HTML, CSS, JS) активна! -->\n<html>\n<head>\n  <style>\n    h1 { color: #4da6ff; transition: 0.3s; }\n    h1:hover { transform: scale(1.1); }\n  </style>\n</head>\n<body>\n  <h1>Привет!</h1>\n  <button onclick=\"sayHi()\">Нажми меня</button>\n\n  <script>\n    function sayHi() {\n      alert('Flexy AI: Всё работает идеально!');\n    }\n  <\/script>\n</body>\n</html>";
    }
    update();
    syncScroll();
  });

  btnCopy.onclick = () => {
    navigator.clipboard.writeText(editor.value);
    const original = btnCopy.innerHTML;
    btnCopy.innerHTML = '<span style="color:#00ff00; font-size:12px; font-weight:bold;">OK</span>';
    setTimeout(() => btnCopy.innerHTML = original, 1000);
  };

  btnTheme.onclick = () => {
    document.body.classList.toggle('light');
  };

  // Логика изменения размеров панелей
  let isDragging = false;
  dragbar.onmousedown = (e) => { isDragging = true; document.body.style.cursor = window.innerWidth > 768 ? 'col-resize' : 'row-resize'; };
  document.onmouseup = () => { isDragging = false; document.body.style.cursor = 'default'; };
  document.onmousemove = (e) => {
    if (!isDragging) return;
    if (window.innerWidth > 768) {
      let x = e.clientX;
      if (x > 100 && x < window.innerWidth - 100) editorWrapper.style.width = x + 'px';
    } else {
      let y = e.clientY;
      if (y > 50 && y < window.innerHeight - 50) editorWrapper.style.height = y + 'px';
    }
  };
</script>
</body>
</html>

main.py:

from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse

app = FastAPI()

# Указываем папку с шаблонами
templates = Jinja2Templates(directory="templates")

@app.get("/", response_class=HTMLResponse)
async def editor(request: Request):
    return templates.TemplateResponse("editor.html", {"request": request})

Версия без подсветки

editor.html:

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Интерактивный HTML редактор</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
  html, body {
    margin: 0;
    padding: 0;
    height: 100%;
    overflow: hidden;
    font-family: monospace, monospace;
    background-color: #121212;
    color: #e0e0e0;
  }
  body {
    display: flex;
    flex-direction: column;
    height: 100%;
  }
  #container {
    flex: 1;
    display: flex;
    width: 100vw;
    height: 100%;
    overflow: hidden;
  }
  .editor-wrapper, #preview {
    box-sizing: border-box;
    overflow: auto;
    height: 100%;
  }
  .editor-wrapper {
    position: relative;
    width: 50%;
    display: flex;
    flex-direction: column;
  }
  textarea#editor {
    flex: 1;
    width: 100%;
    border: none;
    background-color: #1e1e1e;
    color: #e0e0e0;
    font-size: 16px;
    padding: 10px 44px 10px 10px;
    font-family: monospace, monospace;
    resize: none;
    outline: none;
    white-space: pre;
  }
  iframe#preview {
    width: 50%;
    border: none;
    background: white;
  }
  #dragbar {
    width: 5px;
    cursor: col-resize;
    background-color: #444;
    user-select: none;
  }
  button.btn-icon {
    position: absolute;
    top: 8px;
    width: 32px; height: 32px;
    background: transparent;
    border: none;
    cursor: pointer;
    filter: invert(0.65);
    opacity: 0.7;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    transition: opacity 0.25s ease, filter 0.25s ease;
    z-index: 10;
    color: inherit;
  }
  button.btn-icon:hover { opacity: 1; filter: invert(0.9); }
  #btn-copy { right: 44px; }
  #btn-theme { right: 6px; }
  button.btn-icon svg { width: 20px; height:20px; fill: currentColor; }

  body.light {
    background-color: #f5f5f5; color: #222;
  }
  body.light textarea#editor {
    background: #fff; color: #222;
  }
  body.light iframe#preview {
    background: #fff;
  }

  @media (max-width: 768px) {
    #container {
      flex-direction: column !important;
      width: 100vw;
      height: 100%;
    }
    .editor-wrapper, #preview {
      width: 100% !important;
    }
    #dragbar {
      cursor: row-resize;
      width: 100%;
      height: 5px;
    }
    #btn-copy, #btn-theme {
      top: 6px;
      width: 28px;
      height: 28px;
    }
    #btn-copy { right: 40px; }
    #btn-theme { right: 6px; }
  }
</style>
</head>
<body>
  <div id="container">
    <div class="editor-wrapper" role="tabpanel" id="editor-panel" aria-hidden="false">
      <textarea id="editor" placeholder="Пиши HTML здесь..."></textarea>
      <button class="btn-icon" id="btn-copy" title="Скопировать код" aria-label="Скопировать код" type="button" tabindex="0" aria-live="polite" aria-atomic="true" >
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14 c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
      </button>
      <button class="btn-icon" id="btn-theme" title="Переключить тему" aria-label="Переключить тему" type="button" tabindex="0">
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M21 12.79A9 9 0 0112.21 3a7 7 0 108.79 9.79z"/></svg>
      </button>
    </div>
    <div id="dragbar"></div>
    <iframe id="preview" sandbox="allow-scripts allow-same-origin allow-modals" role="tabpanel" aria-hidden="true"></iframe>
  </div>

<script>
  const editor = document.getElementById('editor');
  const preview = document.getElementById('preview');
  const btnCopy = document.getElementById('btn-copy');
  const btnTheme = document.getElementById('btn-theme');
  const body = document.body;
  const container = document.getElementById('container');
  const dragbar = document.getElementById('dragbar');
  const editorWrapper = document.querySelector('.editor-wrapper');

  function updatePreview() {
    preview.srcdoc = editor.value;
  }

  function setSizes() {
    if (window.innerWidth <= 768) {
      const h = window.innerHeight;
      container.style.height = h + 'px';
      const halfHeight = Math.floor(h / 2);
      editorWrapper.style.height = halfHeight + 'px';
      preview.style.height = halfHeight + 'px';
      preview.style.width = '100%';
      editorWrapper.style.width = '100%';
      container.style.flexDirection = 'column';
      dragbar.style.width = '100%';
      dragbar.style.height = '5px';
      dragbar.style.cursor = 'row-resize';

      // Запретить прокрутку body
      document.documentElement.style.overflow = 'hidden';
      document.body.style.overflow = 'hidden';
    } else {
      container.style.height = '';
      editorWrapper.style.height = '100%';
      preview.style.height = '100%';
      editorWrapper.style.width = '50%';
      preview.style.width = '50%';
      container.style.flexDirection = 'row';
      dragbar.style.width = '5px';
      dragbar.style.height = '100%';
      dragbar.style.cursor = 'col-resize';
      document.documentElement.style.overflow = '';
      document.body.style.overflow = '';
    }
  }

  window.addEventListener('load', () => {
    const saved = localStorage.getItem('flexy_code');
    if (saved !== null) {
      editor.value = saved;
      updatePreview();
    }
    setSizes();
  });

  window.addEventListener('resize', setSizes);
  window.addEventListener('orientationchange', setSizes);

  editor.addEventListener('input', () => {
    localStorage.setItem('flexy_code', editor.value);
    updatePreview();
  });

  btnCopy.addEventListener('click', () => {
    const text = editor.value;
    function showCopied() {
      btnCopy.textContent = 'Скопировано \u2713';
      setTimeout(() => {
        btnCopy.innerHTML = `
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14 c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;
      }, 1500);
    }
    if (navigator.clipboard && window.isSecureContext) {
      navigator.clipboard.writeText(text).then(() => showCopied()).catch(() => fallbackCopy());
    } else {
      fallbackCopy();
    }
    function fallbackCopy() {
      editor.select();
      try {
        let success = document.execCommand('copy');
        if (success) showCopied();
        else alert('Не удалось скопировать');
      } catch {
        alert('Не удалось скопировать');
      }
      window.getSelection().removeAllRanges();
    }
  });

  btnTheme.addEventListener('click', () => {
    body.classList.toggle('light');
  });

  // Drag resize
  let isDragging = false;
  dragbar.addEventListener('mousedown', () => {
    isDragging = true;
    document.body.style.userSelect = 'none';
  });
  document.addEventListener('mouseup', () => {
    isDragging = false;
    document.body.style.userSelect = '';
  });
  document.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    if (window.innerWidth > 768) {
      const rect = container.getBoundingClientRect();
      let x = e.clientX - rect.left;
      const min = 100;
      const max = container.clientWidth - min;
      if (x < min) x = min;
      if (x > max) x = max;
      editorWrapper.style.width = x + 'px';
      preview.style.width = (container.clientWidth - x - dragbar.offsetWidth) + 'px';
      editorWrapper.style.height = '100%';
      preview.style.height = '100%';
    } else {
      const rect = container.getBoundingClientRect();
      let y = e.clientY - rect.top;
      const min = 50;
      const max = container.clientHeight - min;
      if (y < min) y = min;
      if (y > max) y = max;
      editorWrapper.style.height = y + 'px';
      preview.style.height = (container.clientHeight - y - dragbar.offsetHeight) + 'px';
      editorWrapper.style.width = '100%';
      preview.style.width = '100%';
    }
  });
</script>
</body>
</html>

main.py:

from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse

app = FastAPI()
templates = Jinja2Templates(directory="templates")

@app.get("/", response_class=HTMLResponse)
async def editor(request: Request):
    return templates.TemplateResponse("editor.html", {"request": request})

Служба myeditor.service:

[Unit]
Description=Uvicorn FastAPI Service myeditor
After=network.target

[Service]
User=root
WorkingDirectory=/root/my_html_editor
ExecStart=/root/my_html_editor/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8083
Restart=always

[Install]
WantedBy=multi-user.target

Служба myeditor.service с TLS сертификатом:

[Unit]
Description=Uvicorn FastAPI Service myeditor
After=network.target

[Service]
User=root
WorkingDirectory=/root/my_html_editor
ExecStart=/root/my_html_editor/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8083 --ssl-certfile /etc/letsencrypt/live/server.tonicman.ru/fullchain.pem --ssl-keyfile /etc/letsencrypt/live/server.tonicman.ru/privkey.pem
Restart=always

[Install]
WantedBy=multi-user.target

Если настроена маскировка (Fallback) для 3X-UI + Nginx:

[Unit]
Description=Uvicorn FastAPI Service myeditor
After=network.target

[Service]
User=root
WorkingDirectory=/root/my_html_editor
ExecStart=/root/my_html_editor/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8083
Restart=always

[Install]
WantedBy=multi-user.target